暫無描述

[id].tsx 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. import { useEffect, useMemo, useState } from 'react';
  2. import {
  3. Alert,
  4. Image,
  5. KeyboardAvoidingView,
  6. Modal,
  7. Platform,
  8. Pressable,
  9. ScrollView,
  10. StyleSheet,
  11. TextInput,
  12. View,
  13. } from 'react-native';
  14. import * as ImagePicker from 'expo-image-picker';
  15. import DateTimePicker from '@react-native-community/datetimepicker';
  16. import { ResizeMode, Video } from 'expo-av';
  17. import { useLocalSearchParams, useRouter } from 'expo-router';
  18. import { ThemedButton } from '@/components/themed-button';
  19. import { IconButton } from '@/components/icon-button';
  20. import { ThemedText } from '@/components/themed-text';
  21. import { ThemedView } from '@/components/themed-view';
  22. import { ZoomImageModal } from '@/components/zoom-image-modal';
  23. import { Colors } from '@/constants/theme';
  24. import { useColorScheme } from '@/hooks/use-color-scheme';
  25. import { useTranslation } from '@/localization/i18n';
  26. import { dbPromise, initCoreTables } from '@/services/db';
  27. type FieldRow = {
  28. id: number;
  29. name: string | null;
  30. };
  31. type CropRow = {
  32. id: number;
  33. field_id: number | null;
  34. crop_name: string | null;
  35. variety: string | null;
  36. planting_date: string | null;
  37. expected_harvest_date: string | null;
  38. photo_uri: string | null;
  39. };
  40. type CropMediaRow = {
  41. uri: string | null;
  42. };
  43. export default function CropDetailScreen() {
  44. const { t } = useTranslation();
  45. const router = useRouter();
  46. const { id } = useLocalSearchParams<{ id?: string | string[] }>();
  47. const cropId = Number(Array.isArray(id) ? id[0] : id);
  48. const theme = useColorScheme() ?? 'light';
  49. const palette = Colors[theme];
  50. const [loading, setLoading] = useState(true);
  51. const [status, setStatus] = useState('');
  52. const [fields, setFields] = useState<FieldRow[]>([]);
  53. const [fieldModalOpen, setFieldModalOpen] = useState(false);
  54. const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
  55. const [cropName, setCropName] = useState('');
  56. const [variety, setVariety] = useState('');
  57. const [plantingDate, setPlantingDate] = useState('');
  58. const [harvestDate, setHarvestDate] = useState('');
  59. const [showPlantingPicker, setShowPlantingPicker] = useState(false);
  60. const [showHarvestPicker, setShowHarvestPicker] = useState(false);
  61. const [mediaUris, setMediaUris] = useState<string[]>([]);
  62. const [activeUri, setActiveUri] = useState<string | null>(null);
  63. const [errors, setErrors] = useState<{ field?: string; crop?: string }>({});
  64. const [zoomUri, setZoomUri] = useState<string | null>(null);
  65. const [saving, setSaving] = useState(false);
  66. const [showSaved, setShowSaved] = useState(false);
  67. useEffect(() => {
  68. let isActive = true;
  69. async function loadCrop() {
  70. if (!Number.isFinite(cropId)) {
  71. setStatus(t('crops.empty'));
  72. setLoading(false);
  73. return;
  74. }
  75. try {
  76. await initCoreTables();
  77. const db = await dbPromise;
  78. const fieldRows = await db.getAllAsync<FieldRow>('SELECT id, name FROM fields ORDER BY name ASC;');
  79. const cropRows = await db.getAllAsync<CropRow>(
  80. 'SELECT id, field_id, crop_name, variety, planting_date, expected_harvest_date, photo_uri FROM crops WHERE id = ? LIMIT 1;',
  81. cropId
  82. );
  83. if (!isActive) return;
  84. setFields(fieldRows);
  85. const crop = cropRows[0];
  86. if (!crop) {
  87. setStatus(t('crops.empty'));
  88. setLoading(false);
  89. return;
  90. }
  91. setSelectedFieldId(crop.field_id ?? null);
  92. setCropName(crop.crop_name ?? '');
  93. setVariety(crop.variety ?? '');
  94. setPlantingDate(crop.planting_date ?? '');
  95. setHarvestDate(crop.expected_harvest_date ?? '');
  96. const mediaRows = await db.getAllAsync<CropMediaRow>(
  97. 'SELECT uri FROM crop_media WHERE crop_id = ? ORDER BY created_at ASC;',
  98. cropId
  99. );
  100. const media = uniqueMediaUris([
  101. ...(mediaRows.map((row) => row.uri).filter(Boolean) as string[]),
  102. ...(normalizeMediaUri(crop.photo_uri) ? [normalizeMediaUri(crop.photo_uri) as string] : []),
  103. ]);
  104. setMediaUris(media);
  105. setActiveUri(media[0] ?? normalizeMediaUri(crop.photo_uri));
  106. } catch (error) {
  107. if (isActive) setStatus(`Error: ${String(error)}`);
  108. } finally {
  109. if (isActive) setLoading(false);
  110. }
  111. }
  112. loadCrop();
  113. return () => {
  114. isActive = false;
  115. };
  116. }, [cropId, t]);
  117. const selectedField = useMemo(
  118. () => fields.find((item) => item.id === selectedFieldId),
  119. [fields, selectedFieldId]
  120. );
  121. const inputStyle = [
  122. styles.input,
  123. {
  124. borderColor: palette.border,
  125. backgroundColor: palette.input,
  126. color: palette.text,
  127. },
  128. ];
  129. async function handleUpdate() {
  130. const nextErrors: { field?: string; crop?: string } = {};
  131. if (!selectedFieldId) {
  132. nextErrors.field = t('crops.fieldRequired');
  133. }
  134. if (!cropName.trim()) {
  135. nextErrors.crop = t('crops.nameRequired');
  136. }
  137. setErrors(nextErrors);
  138. if (Object.keys(nextErrors).length > 0) return;
  139. try {
  140. setSaving(true);
  141. const db = await dbPromise;
  142. const now = new Date().toISOString();
  143. const primaryUri = mediaUris[0] ?? normalizeMediaUri(activeUri);
  144. await db.runAsync(
  145. 'UPDATE crops SET field_id = ?, crop_name = ?, variety = ?, planting_date = ?, expected_harvest_date = ?, photo_uri = ? WHERE id = ?;',
  146. selectedFieldId,
  147. cropName.trim(),
  148. variety.trim() || null,
  149. plantingDate || null,
  150. harvestDate || null,
  151. primaryUri ?? null,
  152. cropId
  153. );
  154. await db.runAsync('DELETE FROM crop_media WHERE crop_id = ?;', cropId);
  155. const mediaToInsert = uniqueMediaUris([
  156. ...mediaUris,
  157. ...(normalizeMediaUri(activeUri) ? [normalizeMediaUri(activeUri) as string] : []),
  158. ]);
  159. for (const uri of mediaToInsert) {
  160. await db.runAsync(
  161. 'INSERT INTO crop_media (crop_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
  162. cropId,
  163. uri,
  164. isVideoUri(uri) ? 'video' : 'image',
  165. now
  166. );
  167. }
  168. setStatus(t('crops.saved'));
  169. setShowSaved(true);
  170. setTimeout(() => {
  171. setShowSaved(false);
  172. setStatus('');
  173. }, 1800);
  174. } catch (error) {
  175. setStatus(`Error: ${String(error)}`);
  176. } finally {
  177. setSaving(false);
  178. }
  179. }
  180. function confirmDelete() {
  181. Alert.alert(
  182. t('crops.deleteTitle'),
  183. t('crops.deleteMessage'),
  184. [
  185. { text: t('crops.cancel'), style: 'cancel' },
  186. {
  187. text: t('crops.delete'),
  188. style: 'destructive',
  189. onPress: async () => {
  190. const db = await dbPromise;
  191. await db.runAsync('DELETE FROM crop_media WHERE crop_id = ?;', cropId);
  192. await db.runAsync('DELETE FROM crops WHERE id = ?;', cropId);
  193. router.back();
  194. },
  195. },
  196. ]
  197. );
  198. }
  199. if (loading) {
  200. return (
  201. <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
  202. <ThemedText>{t('crops.loading')}</ThemedText>
  203. </ThemedView>
  204. );
  205. }
  206. return (
  207. <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
  208. <KeyboardAvoidingView
  209. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  210. style={styles.keyboardAvoid}>
  211. <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
  212. <ThemedText type="title">{t('crops.edit')}</ThemedText>
  213. {status && !showSaved ? <ThemedText>{status}</ThemedText> : null}
  214. <ThemedText>{t('crops.field')}</ThemedText>
  215. <ThemedButton
  216. title={selectedField?.name || t('crops.selectField')}
  217. onPress={() => setFieldModalOpen(true)}
  218. variant="secondary"
  219. />
  220. {errors.field ? <ThemedText style={styles.errorText}>{errors.field}</ThemedText> : null}
  221. <ThemedText>{t('crops.name')}</ThemedText>
  222. <TextInput
  223. value={cropName}
  224. onChangeText={(value) => {
  225. setCropName(value);
  226. if (errors.crop) setErrors((prev) => ({ ...prev, crop: undefined }));
  227. }}
  228. placeholder={t('crops.namePlaceholder')}
  229. placeholderTextColor={palette.placeholder}
  230. style={inputStyle}
  231. />
  232. {errors.crop ? <ThemedText style={styles.errorText}>{errors.crop}</ThemedText> : null}
  233. <ThemedText>{t('crops.variety')}</ThemedText>
  234. <TextInput
  235. value={variety}
  236. onChangeText={setVariety}
  237. placeholder={t('crops.varietyPlaceholder')}
  238. placeholderTextColor={palette.placeholder}
  239. style={inputStyle}
  240. />
  241. <ThemedText>{t('crops.planting')}</ThemedText>
  242. <Pressable onPress={() => setShowPlantingPicker(true)} style={styles.dateInput}>
  243. <ThemedText style={styles.dateValue}>
  244. {plantingDate || t('crops.selectDate')}
  245. </ThemedText>
  246. </Pressable>
  247. {showPlantingPicker ? (
  248. <DateTimePicker
  249. value={plantingDate ? new Date(plantingDate) : new Date()}
  250. mode="date"
  251. onChange={(event, date) => {
  252. setShowPlantingPicker(false);
  253. if (date) setPlantingDate(toDateOnly(date));
  254. }}
  255. />
  256. ) : null}
  257. <ThemedText>{t('crops.harvest')}</ThemedText>
  258. <Pressable onPress={() => setShowHarvestPicker(true)} style={styles.dateInput}>
  259. <ThemedText style={styles.dateValue}>
  260. {harvestDate || t('crops.selectDate')}
  261. </ThemedText>
  262. </Pressable>
  263. {showHarvestPicker ? (
  264. <DateTimePicker
  265. value={harvestDate ? new Date(harvestDate) : new Date()}
  266. mode="date"
  267. onChange={(event, date) => {
  268. setShowHarvestPicker(false);
  269. if (date) setHarvestDate(toDateOnly(date));
  270. }}
  271. />
  272. ) : null}
  273. <ThemedText>{t('crops.addMedia')}</ThemedText>
  274. {normalizeMediaUri(activeUri) ? (
  275. isVideoUri(normalizeMediaUri(activeUri) as string) ? (
  276. <Video
  277. source={{ uri: normalizeMediaUri(activeUri) as string }}
  278. style={styles.mediaPreview}
  279. useNativeControls
  280. resizeMode={ResizeMode.CONTAIN}
  281. />
  282. ) : (
  283. <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
  284. <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
  285. </Pressable>
  286. )
  287. ) : (
  288. <ThemedText style={styles.photoPlaceholder}>{t('crops.noPhoto')}</ThemedText>
  289. )}
  290. {mediaUris.length > 0 ? (
  291. <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
  292. {mediaUris.map((uri) => (
  293. <Pressable key={uri} style={styles.mediaChip} onPress={() => setActiveUri(uri)}>
  294. {isVideoUri(uri) ? (
  295. <View style={styles.videoThumb}>
  296. <ThemedText style={styles.videoThumbText}>▶</ThemedText>
  297. </View>
  298. ) : (
  299. <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
  300. )}
  301. <Pressable
  302. style={styles.mediaRemove}
  303. onPress={(event) => {
  304. event.stopPropagation();
  305. setMediaUris((prev) => {
  306. const next = prev.filter((item) => item !== uri);
  307. setActiveUri((current) => (current === uri ? next[0] ?? null : current));
  308. return next;
  309. });
  310. }}>
  311. <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
  312. </Pressable>
  313. </Pressable>
  314. ))}
  315. </ScrollView>
  316. ) : null}
  317. <View style={styles.photoRow}>
  318. <ThemedButton
  319. title={t('crops.pickFromGallery')}
  320. onPress={() =>
  321. handlePickMedia((uris) => {
  322. if (uris.length === 0) return;
  323. setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
  324. setActiveUri((prev) => prev ?? uris[0]);
  325. })
  326. }
  327. variant="secondary"
  328. />
  329. <ThemedButton
  330. title={t('crops.takeMedia')}
  331. onPress={() =>
  332. handleTakeMedia((uri) => {
  333. if (!uri) return;
  334. setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
  335. setActiveUri((prev) => prev ?? uri);
  336. })
  337. }
  338. variant="secondary"
  339. />
  340. </View>
  341. <View style={styles.actions}>
  342. <IconButton
  343. name="trash"
  344. onPress={confirmDelete}
  345. accessibilityLabel={t('crops.delete')}
  346. variant="danger"
  347. />
  348. <View style={styles.updateGroup}>
  349. {showSaved ? <ThemedText style={[styles.inlineToastText, { color: palette.success }]}>{t('crops.saved')}</ThemedText> : null}
  350. <ThemedButton
  351. title={saving ? t('crops.saving') : t('crops.update')}
  352. onPress={handleUpdate}
  353. disabled={saving}
  354. />
  355. </View>
  356. </View>
  357. </ScrollView>
  358. </KeyboardAvoidingView>
  359. <Modal transparent visible={fieldModalOpen} animationType="fade">
  360. <Pressable style={styles.modalBackdrop} onPress={() => setFieldModalOpen(false)}>
  361. <View style={styles.modalCard}>
  362. <ThemedText type="subtitle">{t('crops.selectField')}</ThemedText>
  363. <ScrollView style={styles.modalList}>
  364. {fields.map((item) => (
  365. <Pressable
  366. key={item.id}
  367. style={styles.modalItem}
  368. onPress={() => {
  369. setSelectedFieldId(item.id);
  370. setFieldModalOpen(false);
  371. }}>
  372. <ThemedText>{item.name || t('crops.untitled')}</ThemedText>
  373. </Pressable>
  374. ))}
  375. </ScrollView>
  376. </View>
  377. </Pressable>
  378. </Modal>
  379. <ZoomImageModal uri={zoomUri} visible={Boolean(zoomUri)} onClose={() => setZoomUri(null)} />
  380. </ThemedView>
  381. );
  382. }
  383. async function handlePickMedia(onAdd: (uris: string[]) => void) {
  384. const result = await ImagePicker.launchImageLibraryAsync({
  385. mediaTypes: getMediaTypes(),
  386. quality: 1,
  387. allowsMultipleSelection: true,
  388. selectionLimit: 0,
  389. });
  390. if (result.canceled) return;
  391. const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[];
  392. if (uris.length === 0) return;
  393. onAdd(uris);
  394. }
  395. async function handleTakeMedia(onAdd: (uri: string | null) => void) {
  396. const permission = await ImagePicker.requestCameraPermissionsAsync();
  397. if (!permission.granted) {
  398. return;
  399. }
  400. const result = await ImagePicker.launchCameraAsync({
  401. mediaTypes: getMediaTypes(),
  402. quality: 1,
  403. });
  404. if (result.canceled) return;
  405. const asset = result.assets[0];
  406. onAdd(asset.uri);
  407. }
  408. function getMediaTypes() {
  409. const mediaType = (ImagePicker as {
  410. MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
  411. }).MediaType;
  412. const imageType = mediaType?.Image ?? mediaType?.Images;
  413. const videoType = mediaType?.Video ?? mediaType?.Videos;
  414. if (imageType && videoType) {
  415. return [imageType, videoType];
  416. }
  417. return imageType ?? videoType ?? ['images', 'videos'];
  418. }
  419. function isVideoUri(uri: string) {
  420. return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
  421. }
  422. function normalizeMediaUri(uri?: string | null) {
  423. if (typeof uri !== 'string') return null;
  424. const trimmed = uri.trim();
  425. return trimmed ? trimmed : null;
  426. }
  427. function uniqueMediaUris(uris: string[]) {
  428. const seen = new Set<string>();
  429. const result: string[] = [];
  430. for (const uri of uris) {
  431. if (!uri || seen.has(uri)) continue;
  432. seen.add(uri);
  433. result.push(uri);
  434. }
  435. return result;
  436. }
  437. function toDateOnly(date: Date) {
  438. return date.toISOString().slice(0, 10);
  439. }
  440. const styles = StyleSheet.create({
  441. container: {
  442. flex: 1,
  443. },
  444. keyboardAvoid: {
  445. flex: 1,
  446. },
  447. content: {
  448. padding: 16,
  449. gap: 10,
  450. paddingBottom: 40,
  451. },
  452. input: {
  453. borderRadius: 10,
  454. borderWidth: 1,
  455. paddingHorizontal: 12,
  456. paddingVertical: 10,
  457. fontSize: 15,
  458. },
  459. errorText: {
  460. color: '#C0392B',
  461. fontSize: 12,
  462. },
  463. dateInput: {
  464. borderRadius: 10,
  465. borderWidth: 1,
  466. borderColor: '#B9B9B9',
  467. paddingHorizontal: 12,
  468. paddingVertical: 10,
  469. },
  470. dateValue: {
  471. opacity: 0.7,
  472. },
  473. mediaPreview: {
  474. width: '100%',
  475. height: 220,
  476. borderRadius: 12,
  477. backgroundColor: '#1C1C1C',
  478. },
  479. photoRow: {
  480. flexDirection: 'row',
  481. gap: 8,
  482. },
  483. actions: {
  484. marginTop: 12,
  485. flexDirection: 'row',
  486. justifyContent: 'space-between',
  487. alignItems: 'center',
  488. gap: 10,
  489. },
  490. photoPlaceholder: {
  491. opacity: 0.6,
  492. },
  493. mediaStrip: {
  494. marginTop: 6,
  495. },
  496. mediaChip: {
  497. width: 72,
  498. height: 72,
  499. borderRadius: 10,
  500. marginRight: 8,
  501. overflow: 'hidden',
  502. backgroundColor: '#E6E1D4',
  503. alignItems: 'center',
  504. justifyContent: 'center',
  505. },
  506. mediaThumb: {
  507. width: '100%',
  508. height: '100%',
  509. },
  510. videoThumb: {
  511. width: '100%',
  512. height: '100%',
  513. backgroundColor: '#1C1C1C',
  514. alignItems: 'center',
  515. justifyContent: 'center',
  516. },
  517. videoThumbText: {
  518. color: '#FFFFFF',
  519. fontSize: 18,
  520. fontWeight: '700',
  521. },
  522. mediaRemove: {
  523. position: 'absolute',
  524. top: 4,
  525. right: 4,
  526. width: 18,
  527. height: 18,
  528. borderRadius: 9,
  529. backgroundColor: 'rgba(0,0,0,0.6)',
  530. alignItems: 'center',
  531. justifyContent: 'center',
  532. },
  533. mediaRemoveText: {
  534. color: '#FFFFFF',
  535. fontSize: 12,
  536. lineHeight: 14,
  537. fontWeight: '700',
  538. },
  539. updateGroup: {
  540. flexDirection: 'row',
  541. alignItems: 'center',
  542. gap: 8,
  543. },
  544. inlineToastText: {
  545. fontWeight: '700',
  546. fontSize: 12,
  547. },
  548. modalBackdrop: {
  549. flex: 1,
  550. backgroundColor: 'rgba(0,0,0,0.4)',
  551. justifyContent: 'center',
  552. padding: 24,
  553. },
  554. modalCard: {
  555. borderRadius: 14,
  556. backgroundColor: '#FFFFFF',
  557. padding: 16,
  558. gap: 10,
  559. maxHeight: '80%',
  560. },
  561. modalList: {
  562. maxHeight: 300,
  563. },
  564. modalItem: {
  565. paddingVertical: 10,
  566. },
  567. });